feat: add external credential resolvers for config values#143
Conversation
Adds $(resolver:params) expression syntax for config file values, allowing secrets to be fetched from external sources instead of stored in plaintext. Three built-in resolvers: - env: read from environment variables - cmd: execute a shell command and use stdout - keychain: read from macOS Keychain (macOS only) Closes #128
- Remove empty ResolverContext interface (and context param) - Attach cause to re-thrown errors in cmd and keychain resolvers
- Skip __proto__ and constructor keys in deep object walk - Reject non-printable characters in keychain service/account params
|
@JoshMock one thing to consider: expression resolution currently runs eagerly across all contexts, not just the active one. This means if you have: current_context: local
contexts:
local:
elasticsearch:
auth:
api_key: $(env:LOCAL_KEY)
staging:
elasticsearch:
auth:
api_key: $(env:STAGING_KEY)Running with Ideally, resolution should be lazy: only resolve expressions for the active context. This requires restructuring the validation pipeline (currently Zod validates the entire config including all contexts, and it would reject unresolved expressions like Worth doing now, or fine to ship eager and follow up? |
Supports $(file:/path/to/secret) syntax, useful for Docker/Kubernetes secrets mounted at /run/secrets/.
- Reject non-regular files (directories, device files, sockets) - Reject files larger than 64 KB
Let's ship the eager version for now and open an issue to track that as a future perf improvement. |
JoshMock
left a comment
There was a problem hiding this comment.
fantastic implementation and impressive turnaround after our discussion yesterday. 👏
honestly, we could consider eventually extracting the resolver out and publishing it as a standalone package just for the OSS karma.
That would be such a cool idea! I'll raise issues to track both |
Summary
Adds support for fetching credentials from external sources via
$(resolver:params)expression syntax in config files, as proposed in #128.file: read from a file, e.g.$(file:/run/secrets/elastic_api_key)env: read from environment variables, e.g.$(env:ELASTIC_API_KEY)cmd: execute a shell command, e.g.$(cmd:pass show elastic/api-key)keychain: read from macOS Keychain, e.g.$(keychain:elastic-cli/api-key)Expressions are resolved after YAML parsing but before Zod validation, so downstream code (schemas, handlers, transport) requires zero changes. Any string field in the config supports expressions.
The resolver registry is extensible via
registerResolver()for future sources.Note: This also updates the README Configuration section to reflect the home-directory-only discovery from #142.
I've taken a stab at this based on the discussion in #128. Happy to tweak the approach or close this if the team prefers a different direction.
Future resolvers
The following could be added as future resolvers. Unlike the four included in this PR (which have zero external dependencies), these would require their respective CLIs or SDKs to be installed:
1password-$(1password:op://vault/item/field)via theopCLIvault-$(vault:secret/data/elastic#api_key)via HashiCorp Vault CLIaws_sm-$(aws_sm:my-secret-name)via AWS CLI (aws secretsmanager get-secret-value)gcp_secret-$(gcp_secret:my-secret/versions/latest)viagcloud secrets versions accessTest plan
npm run build && npm run test:unitpasses (670 tests, 0 failures)npx tsc --noEmitpassesnpx eslint srcpassestest/config/resolvers.test.tscovering:fileresolver (read, trimming, nonexistent file)envresolver (set, unset, empty)cmdresolver (success, failure, error messages)keychainresolver (macOS, non-macOS, format validation, shell escaping)loadConfigpipelineManual testing
Installed the CLI from this branch (
npm run build && npm link) and verified each resolver end-to-end using--dry-run:envresolvercmdresolverkeychainresolver (macOS)